fix(snapshots): reject mismatched resume Content-Range starts - #325
fix(snapshots): reject mismatched resume Content-Range starts#325Kewe63 wants to merge 2 commits into
Conversation
|
Reviewed at What's right
Blocking: rejecting without clearing the
|
| merge | result |
|---|---|
#325 alone onto current main (97f8da0) |
clean |
#139 alone onto current main |
conflict |
#139 on top of main + #325 |
conflict |
#139 already conflicts with main on its own — its merge base is a85368c0, well behind — so it needs a rebase regardless and nothing here is #325's fault. Worth stating plainly so this PR doesn't get blamed for it.
That said, the two do collide textually. The import change auto-resolves because both PRs make a byte-identical edit, but both then rewrite the body of parse_total_size in different ways, and that conflicts:
<<<<<<< HEAD (#325: inlined, using CONTENT_RANGE)
=======
parse_content_range_total(response.headers()) (#139: extracted helper)
>>>>>>> pr139
If both land you end up with parse_content_range_total(&HeaderMap) and parse_content_range_start(&Response) side by side — two near-duplicate parsers of the same header with different signatures. A single parse_content_range() -> Option<ContentRange { start, end, total }> would serve both and remove the conflict entirely. Worth a word with #139's author about who absorbs it.
Also worth noting the two are genuinely complementary: #139's 416 handling covers the case where the .part is already complete, which is a neighbouring branch of the same resume logic.
Carried over from #319 (not blocking, scope discipline here is good)
Listing only because they live in the lines this PR touches, so they're cheap to fold in if a maintainer wants them:
Content-Range: bytes 7-10/*is legal (RFC 9110 §14.4, complete-length may be*). The new start check passes, thenparse_total_sizedoes"*".parse::<u64>()→None→"Server did not provide Content-Length or Content-Range header", which is false — it did.strip_prefix("bytes ")is exact on case and spacing; range units are case-insensitive tokens per RFC 9110.- An inverted range like
bytes 7-3/11still validates, since only the start is compared. - The final
.partlength is still never compared againsttotalbeforerename.
Good, tightly-scoped fix — the remove_file line is the one thing I'd want before it merges.
|
During follow-up testing, I found a second, adjacent Content-Range integrity case that is not covered by the current start-offset validation. A server can return: text Here the returned range start correctly matches the 7-byte .part file, so this PR’s new check accepts the response. However, the header claims four bytes (7..=10) while the response body contains only two. I reproduced this on the current main with a focused regression test. io::copy treats the early EOF as a successful copy, and attempt_download returns: text The resulting file contains only 9 bytes, despite the reported total being 11. Through resumable_download, that incomplete file can then be promoted from .part to the final snapshot filename. The failing assertion was: text This is distinct from #319:
A possible additional invariant would be to verify that:
Would you prefer this integrity check to be included in this PR because it touches the same resume path, or should I keep #319 narrowly scoped and open a separate follow-up issue? |
|
Addressed the blocking resume-lifecycle issue in commit 0493c58. Changes:
Before the fix, both regression tests failed, and the end-to-end test retried the same range ten times before returning an error. Verification after the fix: text The separate short-body 206 case has not been included in this commit and remains pending the scope decision above. @osr21, could you please take another look? |
|
Re-reviewed at 1. The blocking issue is resolved, and you improved on what I suggestedThe recovery path checks out end to end:
On the one place you deviated from my suggestion — I proposed The new Two cosmetic notes, neither worth a commit on their own:
Your commit message says the marker is "retained during recovery." True by construction — nothing on the mismatch path touches it — though the test only asserts it's absent at the end, not that it survived attempt 1. Since From my side the blocking concern is cleared. 2. The short-body case — confirmed, and worse-looking than you describedI verified this independently and it holds. It isn't quite that let result = io::copy(&mut response, &mut writer).and_then(|_| writer.inner.flush());
println!();
result?;
Ok(total)The For the record, this is pre-existing: neither the The fix is smaller than it looks, because the resume path already handles itWorth knowing before anyone scopes it: your invariant (1) is self-sufficient. If a short body returned Two things to write defensively when you get to it, both from the carried-over list in my first review:
On severity, honestlyI'd calibrate this slightly below "silent corruption." A truncated Scope: separate issue, pleaseTo answer your question directly — I'd keep this PR as is and open a new issue.
When you file it, I'd include the self-healing observation above — it turns what reads like a significant integrity fix into a small one, which should help it get picked up. |
Summary
Fixes #319
This fixes resumable snapshot downloads so a HTTP 206 Partial Content response is only appended to an existing
.partfile when the responseContent-Rangestart offset matches the local partial file size.Previously, the downloader sent:
Range: bytes=<existing_size>-
when a
.partfile existed, but it only usedContent-Rangeto read the total size. It did not validate that the returned byte range actually started at<existing_size>.That allowed a mismatched response such as:
Content-Range: bytes 0-3/11
to be appended to a 7-byte
.partfile requested with:Range: bytes=7-
which could corrupt the resumed snapshot.
Changes
CONTENT_RANGEheader constant instead of a string literal.Content-Rangestart parsing for 206 responses.Content-Rangestart does not match the local.partfile size.Content-Rangestart..partfile.Tests
Regression test failed before the fix:
cargo +1.94.0 test -p arc-snapshots resumable_download_rejects_mismatched_content_range_start -- --nocaptureFailure before fix:
mismatched Content-Range should not append to the existing .part file
After the fix:
cargo +1.94.0 test -p arc-snapshots resumable_download_rejects_mismatched_content_range_start -- --nocaptureResult:
1 passed; 0 failed
Related resumable download tests:
cargo +1.94.0 test -p arc-snapshots resumable_download -- --nocaptureResult:
11 passed; 0 failed
Full arc-snapshots package tests:
cargo +1.94.0 test -p arc-snapshotsResult:
108 passed; 0 failed
Formatting and lint:
Result:
passed
Notes
There is an existing open PR #139 touching
crates/snapshots/src/download.rs, but it handles a different resume edge case: treating a fully downloaded.partfile as complete when the server returns 416 with a matching total size. This PR addresses #319 specifically: rejecting mismatched 206Content-Rangestart offsets before appending to.partfiles.Checklist
cargo fmt/cargo clippycleanRisk & Impact
Low. The validation only rejects the specific mismatched-offset case — a correctly-aligned 206 response (
Content-Rangestart matching the local.partsize) is unaffected. Verified the regression test fails against the old behavior and passes with the fix, confirming it exercises the actual corruption path.Type: 🐛 Bug fix
Fixes: #319